Day 23: JUnit 5 테스트
JUnit 5는 Java의 표준 테스트 프레임워크입니다. 테스트 코드는 프로그램이 올바르게 동작하는지 자동으로 확인해주는 검사관 역할을 합니다. 매번 수동으로 확인하는 대신 ./gradlew test 한 줄로 모든 기능을 검증할 수 있습니다.
JUnit 5 기본 테스트
테스트 클래스와 메서드의 기본 구조입니다.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
// 테스트 대상 클래스
class Calculator {
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }
int divide(int a, int b) {
if (b == 0) throw new ArithmeticException("0으로 나눌 수 없습니다");
return a / b;
}
}
// 테스트 클래스
@DisplayName("계산기 테스트")
class CalculatorTest {
private Calculator calc;
@BeforeEach // 각 테스트 메서드 전에 실행
void setUp() {
calc = new Calculator();
}
@Test
@DisplayName("두 수의 덧셈이 올바르게 동작한다")
void testAdd() {
assertEquals(5, calc.add(2, 3));
assertEquals(0, calc.add(-1, 1));
assertEquals(-5, calc.add(-2, -3));
}
@Test
@DisplayName("두 수의 뺄셈이 올바르게 동작한다")
void testSubtract() {
assertEquals(1, calc.subtract(3, 2));
assertEquals(-2, calc.subtract(-1, 1));
}
@Test
@DisplayName("0으로 나누면 ArithmeticException이 발생한다")
void testDivideByZero() {
ArithmeticException exception = assertThrows(
ArithmeticException.class,
() -> calc.divide(10, 0)
);
assertEquals("0으로 나눌 수 없습니다", exception.getMessage());
}
@Test
@Disabled("아직 구현되지 않은 기능")
void testSquareRoot() {
// TODO: 제곱근 기능 추가 후 테스트 작성
}
@AfterEach // 각 테스트 메서드 후에 실행
void tearDown() {
calc = null;
}
}
다양한 Assertion 메서드
테스트 검증에 사용하는 핵심 메서드들입니다.
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class AssertionExamples {
@Test
void basicAssertions() {
// 동등성 비교
assertEquals(4, 2 + 2, "2+2는 4여야 합니다");
assertNotEquals(5, 2 + 2);
// boolean 검증
assertTrue(10 > 5, "10은 5보다 커야 합니다");
assertFalse("".length() > 0);
// null 검증
String name = "Java";
String nullStr = null;
assertNotNull(name);
assertNull(nullStr);
// 같은 객체인지 (참조 비교)
String a = "hello";
String b = a;
assertSame(a, b);
}
@Test
void collectionAssertions() {
List<String> fruits = List.of("사과", "바나나", "포도");
// 크기 확인
assertEquals(3, fruits.size());
// 포함 여부
assertTrue(fruits.contains("사과"));
// 반복 가능 요소 확인 (순서 무관)
assertIterableEquals(
List.of("사과", "바나나", "포도"),
fruits
);
}
@Test
void exceptionAssertions() {
// 예외 발생 확인
assertThrows(NumberFormatException.class, () -> {
Integer.parseInt("abc");
});
// 예외가 발생하지 않는지 확인
assertDoesNotThrow(() -> {
Integer.parseInt("123");
});
}
@Test
void groupedAssertions() {
String name = "홍길동";
int age = 25;
// assertAll: 모든 검증을 한 번에 실행 (하나 실패해도 나머지 계속)
assertAll("사용자 정보 검증",
() -> assertNotNull(name, "이름은 null이 아니어야 합니다"),
() -> assertTrue(name.length() > 0, "이름은 비어있지 않아야 합니다"),
() -> assertTrue(age > 0, "나이는 양수여야 합니다"),
() -> assertTrue(age < 150, "나이는 150 미만이어야 합니다")
);
}
@Test
void timeoutAssertions() {
// 시간 제한 내에 실행 확인
assertTimeout(Duration.ofSeconds(2), () -> {
Thread.sleep(500); // 0.5초 -> 2초 이내이므로 통과
});
}
}
파라미터화 테스트
같은 테스트를 다양한 입력값으로 반복 실행합니다.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
class StringValidator {
boolean isValidEmail(String email) {
return email != null && email.matches("[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
}
boolean isStrongPassword(String password) {
if (password == null || password.length() < 8) return false;
boolean hasUpper = password.chars().anyMatch(Character::isUpperCase);
boolean hasLower = password.chars().anyMatch(Character::isLowerCase);
boolean hasDigit = password.chars().anyMatch(Character::isDigit);
return hasUpper && hasLower && hasDigit;
}
}
class StringValidatorTest {
private final StringValidator validator = new StringValidator();
// @ValueSource: 단일 값 배열
@ParameterizedTest(name = "유효한 이메일: {0}")
@ValueSource(strings = {
"user@example.com",
"test.name@company.co.kr",
"admin+tag@gmail.com"
})
void validEmails(String email) {
assertTrue(validator.isValidEmail(email));
}
@ParameterizedTest(name = "유효하지 않은 이메일: {0}")
@ValueSource(strings = {"", "invalid", "@no-user.com", "no-domain@"})
@NullSource
void invalidEmails(String email) {
assertFalse(validator.isValidEmail(email));
}
// @CsvSource: 여러 인자 조합
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"1, 2, 3",
"0, 0, 0",
"-1, 1, 0",
"100, 200, 300"
})
void testAddition(int a, int b, int expected) {
assertEquals(expected, a + b);
}
// @MethodSource: 메서드에서 인자 제공
@ParameterizedTest(name = "강한 비밀번호: {0} -> {1}")
@MethodSource("passwordProvider")
void testStrongPassword(String password, boolean expected) {
assertEquals(expected, validator.isStrongPassword(password));
}
static Stream<Arguments> passwordProvider() {
return Stream.of(
Arguments.of("Abcdef1!", true),
Arguments.of("StrongP4ss", true),
Arguments.of("weak", false),
Arguments.of("nouppercase1", false),
Arguments.of("NOLOWERCASE1", false),
Arguments.of("NoDigitsHere", false),
Arguments.of(null, false)
);
}
// @EnumSource: enum 값 테스트
@ParameterizedTest
@EnumSource(java.time.Month.class)
void allMonthsAreValid(java.time.Month month) {
assertTrue(month.getValue() >= 1 && month.getValue() <= 12);
}
}
테스트 구조화 (Nested, Lifecycle)
테스트를 논리적으로 그룹화하고 생명주기를 관리합니다.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.List;
@DisplayName("쇼핑 카트 테스트")
class ShoppingCartTest {
private List<String> cart;
@BeforeAll // 전체 테스트 전 한 번 실행
static void initAll() {
System.out.println("테스트 시작");
}
@BeforeEach
void setUp() {
cart = new ArrayList<>();
}
@Nested
@DisplayName("비어 있는 카트에서")
class EmptyCart {
@Test
@DisplayName("항목 수는 0이다")
void isEmpty() {
assertTrue(cart.isEmpty());
assertEquals(0, cart.size());
}
@Test
@DisplayName("항목 추가 후 크기가 1이 된다")
void addItem() {
cart.add("노트북");
assertEquals(1, cart.size());
assertTrue(cart.contains("노트북"));
}
}
@Nested
@DisplayName("항목이 있는 카트에서")
class NonEmptyCart {
@BeforeEach
void addItems() {
cart.add("노트북");
cart.add("마우스");
cart.add("키보드");
}
@Test
@DisplayName("항목 수는 3이다")
void hasThreeItems() {
assertEquals(3, cart.size());
}
@Test
@DisplayName("특정 항목을 삭제할 수 있다")
void removeItem() {
cart.remove("마우스");
assertEquals(2, cart.size());
assertFalse(cart.contains("마우스"));
}
@Test
@DisplayName("전체 비우기가 동작한다")
void clearCart() {
cart.clear();
assertTrue(cart.isEmpty());
}
@Test
@DisplayName("중복 항목 추가가 가능하다")
void duplicateItem() {
cart.add("노트북");
assertEquals(4, cart.size());
assertEquals(2, cart.stream().filter("노트북"::equals).count());
}
}
@AfterAll
static void tearDownAll() {
System.out.println("테스트 완료");
}
}
오늘의 연습문제
-
문자열 유틸 테스트:
StringUtils클래스에reverse(),isPalindrome(),countVowels()메서드를 만들고, 각각에 대한 JUnit 5 테스트를 작성하세요. 정상 케이스, 경계 케이스, null/빈 문자열 케이스를 포함하세요. -
파라미터화 테스트: 주민등록번호 검증 메서드를 만들고,
@CsvSource와@MethodSource를 활용하여 유효/무효 번호 세트로 파라미터화 테스트를 작성하세요. -
TDD 실습: RED-GREEN-REFACTOR 사이클로
Stack<T>클래스를 구현하세요. 먼저 실패하는 테스트를 작성하고, 테스트를 통과하는 최소한의 구현을 하고, 리팩토링하세요.push,pop,peek,isEmpty,size, 언더플로우 예외를 테스트하세요.